Detection of Obstructive Sleep Apnea using Deep Learning on Audio Signals
Part 1: Tracheal Data
Obstructive sleep apnea (OSA) is a common breathing disorder that is rarely detected early because it appears normal but is associated with serious long-term health risks. This study analyzes a noninvasive approach for OSA detection using acoustic analysis. A deep learning model trained with tracheal audio recordings collected during PSG sessions is proposed.
The recordings were segmented into 10-s clips, transformed into Mel spectrograms, and classified using a convolutional neural network (CNN) based on the EfficientNetV2B1 architecture. The model achieved an accuracy greater than 90%, an F1 score of 91%, and an AUC-ROC of 0.96 on the test set. These results indicate the potential of audio-based deep learning systems as cost-effective and scalable tools for early detection of OSA both in clinics and remotely.
Dataset Description¶
For this project, the PSG-AUDIO dataset was selected, which contains polysomnography (PSG) signals from 212 patients. However, only 192 patients were included in this study due to they come along with a cleaner version of the annotation files available.
Each patient PSG recording includes tracheal and ambient microphone signals. Tracheal microphone signals (High-quality contact microphone placed on trachea) were selected, they contain cleaner audio compared to the ambient microphone signal.
The tracheal microphone signals:
- originally sampled at 48 kHz in 'float64` format, a frequency that captures a broad spectrum of sounds.
- These audio signals come along with event annotations, which labels the initial time and duration of a variety of sleep disorder cases: hypopnea, obstructive apnea, mixed apnea and central apnea.
In this project, **just obstructive apnea events were considered*, the other events were excluded and the rest of recorded time is assumed to be normal sleep.
Data Preprocessing¶
A preliminary analysis of the distribution of obstructive apnea episode durations across the dataset revealed that the majority last 10 seconds of more. A fixed segment duration of 10 seconds was chosen for all samples created, this also follows the minimum duration stated for an apnea episode defined by the American Academy of Sleep Medicine.
For Non-apnea (normal sleep) and Obstructive apnea segments:
- Directly extracted from medical annotations by dividing each annotated event into 10-second windows.
- For non-apnea (normal sleep) segments, 10-second windows were also extracted, but only from periods that were at least 30 seconds away from any annotated apnea episode.
This was introduced to reduce the risk of including transitional or ambiguous events that could compromise the distinction between apnea and non-apnea labels.
Signal denoising Pipeline¶
- Harmonic-percussive source separation (HPSS): which effectively separates the stable tonal components (the hum, likely coming from nearby medical equipment, such as ventilators or refrigerators) from the percussive (respiration-related) elements.
- Polyphase filtering: Downsample signals from 48 khz to 16 kHz. This frequency is suitable for capturing respiratory sounds, which are typically lower than 8 kHz. This approach helped reduce both storage requirements and computational demands.
Feature Extraction¶
A Mel spectrogram was created for each of the audio segments processed.
- This representation takes audio signals from the time-domain and converts them into a 2D form of a time-frequency representation.
- Mel spectrograms are common in biomedical audio classification.
Spectrograms were created with: 256 Mel frequency bands, 2048-point short time Fourier transform (STFT) and 512-point hop, resulting in Mel spectrograms of shape (256, 313). These all provided a reasonable balance between time resolution and frequency resolution. Next, the Mel spectrograms were converted to decibels (dB).
Such transformation effectively reduces the dynamic range and refines the representation of the features. Lastly, the spectrograms were scaled to the [0,1], consistent with practices, particularly as input normalization for deep learning.
Dataset Distribution¶
To correctly estimate the model performance, the dataset was divided into training, validation and test sets in proportion of 70%, 15% and 15% of the total dataset, but considering samples distribution per patient. This was possible with the implementation of StratifiedShuffleSplit over a metadata file with the samples distribution information per patient. Creating patient-based sets is mandatory to avoid potential data leakage and allow the model to generalize to patients that are unseen (more realistic estimate of clinical environment performance).
With this considerations, the final distribution of samples for the model training and evaluation can be created:
| Group | Normal | Obstructive Apnea | Total | Sample Ratio |
|---|---|---|---|---|
| Train | 26769 | 27301 | 54070 | 0.50 |
| Validation | 5856 | 5790 | 11646 | 0.50 |
| Test | 5402 | 5880 | 11282 | 0.52 |
In this way the class distribution becomes stable (near 50% of each class).
Finally, the samples selected were stored in float32 format as tfrecord files per patient, for storage and computational efficiently during model training/evaluation. The size of the complete dataset becomes 66.17 Gb.
Model Training and Evaluation¶
The Mel Spectrogram samples created allows us to establish this project as a binary classificaiton:
- 1: Obstructive Sleep Apnea
- 0: Non-apnea (Normal Sleep)
In this project, I propose a deep learning architecture based on a EfficientNetv2B1 feature extractor attached to a CNN constructed in tensorflow for binary classification of apnea versus non-apnea segments.
| Total params: | 6,971,573 (26.59 MB) |
|---|---|
| Trainable params: | 6,900,501 (26.32 MB) |
| Non-trainable params: | 71,072 (277.62 KB) |
More precisely, during model training is included:
- mixed-precision, which allows better utilization of GPU hardware and less consumption of memory without negatively affecting model performance.
- cosine annealing learning rate schedule with the AdamW optimizer was implemented, initialized with a learning rate of 4e−4, to enhance the stability of convergence and provide better generalization.
- class weights were estimated according to the samples labels in training set. To mitigate the effects of class imbalance during training.
The evaluation metrics used to estimate model performance are: accuracy, f1-score, recall, precision, auc-roc.
Import Libraries
¶import matplotlib.pyplot as plt
import seaborn as sns
import tensorflow as tf
from tensorflow.keras import layers
import os
from glob import glob
import numpy as np
import warnings
warnings.filterwarnings("ignore", category=RuntimeWarning)
try:
resolver = tf.distribute.cluster_resolver.TPUClusterResolver(tpu='local')
tf.config.experimental_connect_to_cluster(resolver)
# This is the TPU initialization code that has to be at the beginning.
tf.tpu.experimental.initialize_tpu_system(resolver)
#`tf.distribute.experimental.TPUStrategy` is deprecated
strategy = tf.distribute.TPUStrategy(resolver)
#print("All devices: ", tf.config.list_logical_devices('TPU'))
except:
strategy = tf.distribute.get_strategy()
from tensorflow.keras import mixed_precision
# NVIDIA GPUs support using a mix of float16 and float32,
# while TPUs and Intel CPUs support a mix of bfloat16 and float32.
if 'tpu' in strategy.cluster_resolver.__str__():
print('Using TPU mixed precision')
policy = mixed_precision.Policy('float32') #bfloat16') no mixing precision
else:
policy = mixed_precision.Policy('mixed_float16')
mixed_precision.set_global_policy(policy)
Dataset Preparation
¶# look for number of samples per patient
from tqdm.auto import tqdm
import pandas as pd
def analyze_tfrecord(file_path):
_count = {i:0 for i in range(5)}
dataset = tf.data.TFRecordDataset(file_path)
dataset = dataset.map(parse_example)
for record in dataset:
_,(t_label, t_label_class) = record
_count[t_label_class.numpy()] += 1
return _count
basedir = "/kaggle/input/apnea-audio-tfrecords/APNEA_MIC"
_data = []
for _file in tqdm( glob(os.path.join(basedir, "*")) ):
_count = analyze_tfrecord(_file)
_count["file"] = _file
_data += [_count]
_df = pd.DataFrame(_data) #, columns=["File","Apnea_Count", "Normal_Count"])
_df.to_csv("_apnea_label_dist.csv", index=False)
Train, Val, Test Dataset Distribution¶
Validation per Fold must be considered
When you split by patient, it is mandatory to guarante that each subset (train/val/test) will have:
- The same proportion of Apnea vs. Normal, or
- The same distribution of patient types, e.g.:
- Some patients might have a high apnea index (AHI)
- Others might be mostly normal sleepers
This is needed to avoid:
- Val/Test sets with unusual balance, making it harder for the model to generalize
- Sudden drops in validation performance, like the ones you observed
import pandas as pd
df_dist = pd.read_csv("/kaggle/input/apnea-class-aux-files/_apnea_label_dist.csv")
# just obstructive apnea
df_dist["apnea_count"] = df_dist[['3']] #df_dist[['1','2','3','4']].sum(axis=1)
df_dist['apnea_ratio'] = df_dist['apnea_count'] / (df_dist['0'] + df_dist['apnea_count'])
# Option 1: Bin by ratio into quartiles or deciles
# Option 2: Use custom bins
bins = np.arange(0, 1.1, 0.1)
# include_lowest to avoid nan
df_dist['ratio_bin'] = pd.cut(df_dist['apnea_ratio'], bins=bins, labels=False, include_lowest=True)
#df_dist
# for version 3 only
df_dist['file'] = (df_dist['file'].str.replace("APNEA_MIC/", "")
.str.replace("apnea-audio-tfrecords","apnea-trach-mic")
.str.replace("apnea-trach-mic", "apnea-trach-mic-filtered")
)
from sklearn.model_selection import StratifiedShuffleSplit
def _show_dist():
_1 = train_df['ratio_bin'].value_counts(normalize=True).reset_index().rename(columns={"proportion":"Train"})
_2 = val_df['ratio_bin'].value_counts(normalize=True).reset_index().rename(columns={"proportion":"Validation"})
_3 = test_df['ratio_bin'].value_counts(normalize=True).reset_index().rename(columns={"proportion":"Test"})
_dist = pd.merge(_1, _2, on="ratio_bin", how="left")
_dist = pd.merge(_dist, _3, on="ratio_bin", how="left").sort_values(by="ratio_bin")
_dist["ratio_bin"] = _dist["ratio_bin"]/10
return _dist.rename(columns={"ratio_bin":"Apnea Samples Group"}).round(3)
splitter = StratifiedShuffleSplit(n_splits=1, test_size=0.3, random_state=42)
X = df_dist['file']
y = df_dist['ratio_bin']
# First split: train vs. temp (val + test)
for train_idx, temp_idx in splitter.split(X, y):
train_df = df_dist.iloc[train_idx]
temp_df = df_dist.iloc[temp_idx]
# Second split: val vs. test (split temp again)
splitter_2 = StratifiedShuffleSplit(n_splits=1, test_size=0.5, random_state=42)
# exclude ratio_bin group with just one element
exclude_groups = [_bin for _bin, cond in (temp_df['ratio_bin'].value_counts()==1).items() if cond==True]
temp_df = temp_df[temp_df['ratio_bin'].apply(lambda x: x not in exclude_groups)]
X_temp = temp_df['file']
y_temp = temp_df['ratio_bin']
for val_idx, test_idx in splitter_2.split(X_temp, y_temp):
val_df = temp_df.iloc[val_idx]
test_df = temp_df.iloc[test_idx]
_show_dist()
| Apnea Samples Group | Train | Validation | Test | |
|---|---|---|---|---|
| 9 | 0.0 | 0.015 | NaN | NaN |
| 5 | 0.1 | 0.060 | 0.071 | 0.034 |
| 2 | 0.2 | 0.164 | 0.143 | 0.172 |
| 1 | 0.3 | 0.224 | 0.214 | 0.241 |
| 0 | 0.4 | 0.239 | 0.250 | 0.241 |
| 3 | 0.5 | 0.082 | 0.071 | 0.103 |
| 6 | 0.6 | 0.052 | 0.071 | 0.034 |
| 8 | 0.7 | 0.045 | 0.036 | 0.069 |
| 4 | 0.8 | 0.075 | 0.071 | 0.069 |
| 7 | 0.9 | 0.045 | 0.071 | 0.034 |
The proportion of Apnea Samples per patient were balanced as much as possible.
Dataset Pipeline¶
# (256, 313)
N_MELS = 256
mel_time_frames = 313
def parse_example(example_proto):
feature_description = {
'mel_spec_mic': tf.io.FixedLenFeature([N_MELS * mel_time_frames], tf.float32), # Flattened mel spectrogram
'mel_spec_trach': tf.io.FixedLenFeature([N_MELS * mel_time_frames], tf.float32),
'label': tf.io.FixedLenFeature([], tf.float32), # Label as float32
'ap_type': tf.io.FixedLenFeature([], tf.int64)
}
parsed = tf.io.parse_single_example(example_proto, feature_description)
# Reshape the flattened mel_spec back to the 2D spectrogram shape
mel_spec_mic = tf.reshape(parsed['mel_spec_mic'], [N_MELS, mel_time_frames])
mel_spec_trach = tf.reshape(parsed['mel_spec_trach'], [N_MELS, mel_time_frames])
#normalize mel_spec range -80dB to 0dB
min_db=-80.0; max_db=0.0
mel_spec_mic = (mel_spec_mic - min_db) / (max_db - min_db)
mel_spec_trach = (mel_spec_trach - min_db) / (max_db - min_db)
#mel_comb = tf.stack([mel_spec_mic, mel_spec_trach], axis=-1)
return (mel_spec_mic, mel_spec_trach), (tf.expand_dims(parsed['label'], axis=-1), parsed['ap_type'])
train_files = train_df['file'].values
valid_files = val_df['file'].values
test_files = test_df['file'].values
def fast_filter_fn(min_std=0.006):
"""
heuristic of pixel distribution, avoid empty Mel Spectrograms
Do not hurt to set minimum 0.006
- min std tracheal 0.006
- min std mic 0.004
"""
def inner_fn(image, label):
std = tf.math.reduce_std(image)
return std > min_std
return inner_fn
#map_type = {0:'Normal', 1:'Hypopnea', 2:'MixedApnea', 3:'ObstructiveApnea', 4:'CentralApnea'}
def keep_only_0_or_3(mel_spec, label):
return tf.logical_or(tf.equal(label[1], 0), tf.equal(label[1], 3))
BATCH_SIZE = 16 #32 #64#//2
GLOBAL_BATCH_SIZE = BATCH_SIZE*strategy.num_replicas_in_sync
############################################
input_data = "Tracheal" # "Tracheal", "Mic"
#input_data = "Mic"
if input_data == "Mic":
_type = 0
else:
_type = 1
############################################
# Load TFRecord Dataset
train_ds = (
tf.data.Dataset.from_tensor_slices(train_files)
.interleave(
lambda x: tf.data.TFRecordDataset(x),
cycle_length=tf.data.AUTOTUNE, # Number of files to read in parallel
num_parallel_calls=tf.data.AUTOTUNE, # Number of parallel calls for interleave
deterministic=False # Allow non-deterministic order for speed
)
.map(parse_example, num_parallel_calls=tf.data.AUTOTUNE)
.filter(keep_only_0_or_3) # just obstructive apnea
# combined structure t_images -> (mic, tracheal), t_labels -> (label, label_type)
.map(lambda t_images, t_labels: (t_images[_type], t_labels[0]), num_parallel_calls=tf.data.AUTOTUNE)
.filter(fast_filter_fn(min_std=0.006))
#.cache()
.shuffle(5000)
.batch(GLOBAL_BATCH_SIZE)
.prefetch(tf.data.AUTOTUNE)
)
valid_ds = (
tf.data.Dataset.from_tensor_slices(valid_files)
.interleave(
lambda x: tf.data.TFRecordDataset(x),
cycle_length=tf.data.AUTOTUNE, # Number of files to read in parallel
num_parallel_calls=tf.data.AUTOTUNE, # Number of parallel calls for interleave
deterministic=False # Allow non-deterministic order for speed
)
.map(parse_example, num_parallel_calls=tf.data.AUTOTUNE)
.filter(keep_only_0_or_3) # just obstructive apnea
# combined structure t_images -> (mic, tracheal), t_labels -> (label, label_type)
.map(lambda t_images, t_labels: (t_images[_type], t_labels[0]), num_parallel_calls=tf.data.AUTOTUNE)
.filter(fast_filter_fn(min_std=0.006))
#.cache()
.batch(GLOBAL_BATCH_SIZE)
.prefetch(tf.data.AUTOTUNE)
)
test_ds = (
tf.data.Dataset.from_tensor_slices(test_files)
.interleave(
lambda x: tf.data.TFRecordDataset(x),
cycle_length=tf.data.AUTOTUNE, # Number of files to read in parallel
num_parallel_calls=tf.data.AUTOTUNE, # Number of parallel calls for interleave
deterministic=False # Allow non-deterministic order for speed
)
.map(parse_example, num_parallel_calls=tf.data.AUTOTUNE)
.filter(keep_only_0_or_3) # just obstructive apnea
# combined structure t_images -> (mic, tracheal), t_labels -> (label, label_type)
.map(lambda t_images, t_labels: (t_images[_type], t_labels[0]), num_parallel_calls=tf.data.AUTOTUNE)
.filter(fast_filter_fn(min_std=0.006))
#.cache() # to have a deterministic test_ds
.batch(GLOBAL_BATCH_SIZE)
.prefetch(tf.data.AUTOTUNE)
)
print(f"\nUsing {input_data}, batches {GLOBAL_BATCH_SIZE}\n")
Using Tracheal, batches 16
t_images, t_labels = valid_ds.take(1).as_numpy_iterator().next()
f,axs = plt.subplots(nrows=4, ncols=4)
for i, ax in enumerate(axs.ravel()):
ax.imshow(t_images[i][::-1], cmap="magma")
ax.axis("off")
plt.show()
Class Weights Estimation¶
from collections import Counter
from tqdm.auto import tqdm
#class_w = {0:0, 1:0}
#for t_images, t_labels in tqdm(train_ds):
# _count = Counter(t_labels.numpy().reshape(-1))
# class_w[0] += _count[0.0]
# class_w[1] += _count[1.0]
#_total = class_w[0] + class_w[1]
#class_w[0] = _total / (2 * class_w[0])
#class_w[1] = _total / (2 * class_w[1])
#class_w
class_w = {0: 1.0107712111460265, 1: 0.9894559335853532}
Model Training and Evaluation
¶def generate_model(freeze_enc=True):
base_model = tf.keras.applications.EfficientNetV2B1(include_top=False,
weights=None, #'imagenet',
input_shape=(128*2, 313, 1),
include_preprocessing=False)
if freeze_enc:
base_model.trainable=False
x = layers.GlobalAveragePooling2D()(base_model.output)
x = layers.Dense(32, activation="swish")(x)
x = layers.Dropout(rate=0.2)(x)
x = layers.Dense(1, activation="sigmoid", dtype="float32")(x)
return tf.keras.Model(inputs= base_model.input, outputs=x)
Cosine annealing learning rate schedule¶
import math
def warmup_cosine_lr(base_lr, total_epochs, warmup_epochs=5, min_lr=0.0):
"""
Compute the learning rate for a given epoch using warmup + cosine annealing.
Args:
epoch (int): Current epoch number (0-based).
base_lr (float): The initial learning rate after warmup.
total_epochs (int): Total number of training epochs.
warmup_epochs (int): Number of warmup epochs at the start of training.
min_lr (float): Minimum learning rate at the end of cosine annealing.
Returns:
float: Adjusted learning rate.
"""
def inner_function(epoch):
if epoch < warmup_epochs:
# Linear warmup
return base_lr * (epoch + 1) / warmup_epochs
else:
# Cosine annealing
cosine_epochs = total_epochs - warmup_epochs
epoch_in_cosine = epoch - warmup_epochs
cosine_decay = 0.5 * (1 + math.cos(math.pi * epoch_in_cosine / cosine_epochs))
return min_lr + (base_lr - min_lr) * cosine_decay
return inner_function
# Example
lr_scheduler = warmup_cosine_lr(base_lr=4e-4, total_epochs=35, warmup_epochs=8, min_lr=1e-6)
x = list(range(35))
y = [lr_scheduler(e) for e in x]
f,ax=plt.subplots(figsize=(8,4))
ax.plot(x,y)
ax.set_title("Cosine annealing Scheduler")
ax.set_xlabel("Epoch")
ax.set_ylabel("Learning Rate")
Text(0, 0.5, 'Learning Rate')
Model Training¶
with strategy.scope():
##########################
model = generate_model(freeze_enc=False)
model.load_weights("/kaggle/input/apnea-ml-weights/model_type_1.weights.h5")
##########################
optimizer = tf.keras.optimizers.AdamW(learning_rate=0.0004) # clipnorm=1.0)
#optimizer = mixed_precision.LossScaleOptimizer(optimizer, dynamic=True) # avoid underflow
model.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=False),
optimizer= optimizer,
metrics=['accuracy',
tf.keras.metrics.F1Score(threshold=0.5)])
from tensorflow.keras.callbacks import EarlyStopping
lr_scheduler = warmup_cosine_lr(base_lr=3e-4, total_epochs=35, warmup_epochs=10, min_lr=1e-6)
callback_lr = tf.keras.callbacks.LearningRateScheduler(lr_scheduler)
early_stop = EarlyStopping(monitor='val_loss',
min_delta=0,
patience=5,
mode='auto',
restore_best_weights=True
)
history = model.fit(train_ds,
validation_data=valid_ds,
epochs=40,
callbacks=[early_stop, callback_lr],
class_weight= class_w)
# Save weights only
model.save_weights(f'model_type_{_type}.weights.h5')
#model.load_weights(f'model_type_{_type}.weights.h5')
#model.load_weights("/kaggle/input/apnea-ml-weights/model_type_1_gpu.weights.h5")
f,ax = plt.subplots(ncols=3, figsize=(12,4))
ax[0].set_title("Accuracy")
ax[1].set_title("F1 Score")
ax[2].set_title("Loss")
x_seg = list(range(1,len(history['accuracy'])+1))
ax[0].plot(x_seg, history['accuracy'], color="navy", label="accuracy")
ax[0].plot(x_seg, history['val_accuracy'], color="orange", label="val_accuracy")
ax[1].plot(x_seg, history['f1_score'], color="navy", label="f1-score")
ax[1].plot(x_seg, history['val_f1_score'], color="orange", label="val_f1-score")
ax[2].plot(x_seg, history['loss'], color="navy", label="loss")
ax[2].plot(x_seg, history['val_loss'], color="orange", label="val_loss")
for i in range(3):
ax[i].legend()
ax[i].set_xlabel("Epoch")
ax[i].grid(True)
#ax[i].set_xlim([1,11])
plt.tight_layout()
plt.show()
f.savefig("loss_trach_epoch.pdf", dpi=300, bbox_inches='tight')
Results Analysis
¶Mel Spectrogram Apnea type: Obstructive Apnea
Tracheal Data using EfficientNetV2B1 trained over GPU.
Metrics Analysis¶
# test_ds other can change if called multiple times, due to interleave, prefetch, a deterministic test_ds must use .cache()
y_true = []
y_pred_proba = []
for batch_x, batch_y in tqdm( test_ds ):
_preds = model.predict(batch_x, verbose=0)
y_pred_proba.extend(_preds.flatten()) # Binary threshold
y_true.extend(batch_y.numpy().flatten())
0it [00:00, ?it/s]
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR I0000 00:00:1757266699.632271 96 service.cc:148] XLA service 0x7cf4c40103e0 initialized for platform CUDA (this does not guarantee that XLA will be used). Devices: I0000 00:00:1757266699.632960 96 service.cc:156] StreamExecutor device (0): Tesla P100-PCIE-16GB, Compute Capability 6.0 I0000 00:00:1757266700.607146 96 cuda_dnn.cc:529] Loaded cuDNN version 90300 I0000 00:00:1757266705.664046 96 device_compiler.h:188] Compiled cluster using XLA! This line is logged at most once for the lifetime of the process.
Threshold Tuning (Precision, Recall, F1 vs Threshold)¶
import numpy as np
from sklearn.metrics import precision_recall_fscore_support
thresholds = np.linspace(0.0, 1.0, 101)
precision, recall, f1 = [], [], []
for t in thresholds:
preds = (y_pred_proba >= t).astype(int)
p, r, f, _ = precision_recall_fscore_support(y_true, preds, average='binary', zero_division=0)
precision.append(p)
recall.append(r)
f1.append(f)
f,ax = plt.subplots(figsize=(6, 3))
ax.plot(thresholds, precision, label='Precision')
ax.plot(thresholds, recall, label='Recall')
ax.plot(thresholds, f1, label='F1 Score')
ax.axvline(0.3, color='navy', linestyle='--', label='threshold=0.3')
ax.axvline(0.5, color='gray', linestyle='--', label='Default threshold 0.5')
ax.set_xlabel('Threshold')
ax.set_ylabel('Score')
ax.set_title('Precision, Recall, and F1 Score vs Threshold')
plt.legend(loc='lower right')
plt.grid(lw=0.25)
plt.show()
#f.savefig("tr_metrics.pdf", dpi=300, bbox_inches='tight')
In the graphic is possible to see that F1-score is maximized around a threshold of 0.3 without a representative loss in general precision.
tr = 0.3 #0.5
y_pred = (np.array(y_pred_proba) >= tr).astype(int)
from sklearn.metrics import confusion_matrix
# True and predicted labels
cm = confusion_matrix(y_true, y_pred)
# Convert to percentage (row-wise or total)
cm_percent = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis] * 100
# Plot
f, ax = plt.subplots(figsize=(4, 3))
sns.heatmap(cm_percent, annot=True, fmt=".1f", cmap="Blues", xticklabels=['Normal', 'OSA'], yticklabels=['Normal', 'OSA'])
ax.set_xlabel("Predicted")
ax.set_ylabel("True")
ax.set_title("Confusion Matrix (% per class)")
plt.show()
f.savefig("confusion_matrix.pdf", dpi=300, bbox_inches='tight')
from sklearn.metrics import roc_curve, roc_auc_score, RocCurveDisplay
fpr, tpr, thresholds = roc_curve(y_true, y_pred_proba)
auc = roc_auc_score(y_true, y_pred_proba)
plt.figure(figsize=(7,4))
RocCurveDisplay(fpr=fpr, tpr=tpr, roc_auc=auc, estimator_name='Model').plot()
plt.title(f"ROC Curve (AUC = {auc:.2f})")
plt.grid(True)
plt.show()
<Figure size 700x400 with 0 Axes>
# Step 4: Classification report
from sklearn.metrics import classification_report
print(classification_report(y_true, y_pred, target_names=["No Apnea", "Apnea"]))
precision recall f1-score support
No Apnea 0.90 0.91 0.90 5402
Apnea 0.91 0.91 0.91 5880
accuracy 0.91 11282
macro avg 0.91 0.91 0.91 11282
weighted avg 0.91 0.91 0.91 11282
The evaluation results reflect the model’s performance on test set:
- Overall accuracy of 91%
- With classification thresholds of 0.3: F1-score of 91%.
The absence of significant differences in Recall between apnea and non-apnea classes suggests that the model is consistently capable of detecting apnea events while correctly identifying normal sleep, supporting its reliability for practical use.
Good Examples¶
import librosa
def plot_examples(batch_x, batch_y, good_ex=False, j=0):
preds = (model.predict(batch_x) > 0.5).astype("int").flatten()
batch_y = batch_y.numpy().reshape(-1).astype("int")
index_l = np.where(preds ^ batch_y == (0 if good_ex else 1) )[0] # xor ^ comparison
if len(index_l)==0: return None
nplots = len(index_l) + ((4 - len(index_l)%4) if len(index_l)%4 !=0 else 0)
nrows = nplots//4
#f,ax=plt.subplots(nrows=nrows, ncols=4, figsize=(10, 2*nrows))
f,ax=plt.subplots(nrows=2, ncols=2, figsize=(6, 4))
for i, _ax in enumerate(ax.ravel()):
if i<len(index_l):
_ax.set_title(f"Real {batch_y[index_l[i]]}, Predicted {preds[index_l[i]]}")
librosa.display.specshow(batch_x[index_l[i]].numpy(), x_axis='time', y_axis='mel', ax=_ax, sr=16000)
#_ax.imshow(batch_x[index_l[i]][::-1], cmap="magma")
else:
_ax.set_visible(False)
f.tight_layout()
f.savefig(("good_ex" if good_ex else "bad_ex") + f"{j}.pdf", dpi=300, bbox_inches='tight')
for i, (batch_x, batch_y) in enumerate(test_ds):
plot_examples(batch_x, batch_y, good_ex=True)
if i>0: break
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 50ms/step 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 51ms/step
Bad examples analysis¶
for i, (batch_x, batch_y) in enumerate(test_ds):
plot_examples(batch_x, batch_y, good_ex=False, j=i)
#plot_examples(batch_x, batch_y, good_ex=True, j=i)
if i>0: break
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 51ms/step 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 53ms/step
Certain apnea events were misclassified as normal sleep, likely from subtle or unclear breathing patterns.
These examples suggest the need for future work focused on challenging cases, potentially through advanced noise reduction techniques like denoising autoencoders and the inclusion of data from non-clinical environments to improve generalization.
In general, the results support an effective approach to OSA detection and breathing analysis.